Mesh Mosaics¶

A mosaic is a custom collection of perspectives for the same scene. With ipyniivue, you use the set_slice_mosaic_string to define each tile for a lightbox view. Note that the load_meshes() command is asynchronous, so we need to use the on_mesh_loaded() to set the mosaic string after the mesh is available.

This Jupyter notebook mirrors the mesh mosaic web page.

Note boggle atlas will require upgrade to NiiVue 0.66

In [1]:
from pathlib import Path

from ipyniivue import download_dataset

BASE_API_URL = "https://niivue.com/demos/images/"
DATA_FOLDER = Path("images")

download_dataset(
    BASE_API_URL,
    DATA_FOLDER,
    files=[
        "lh.pial",
        "lh.curv",
        "boggle.lh.annot",
    ],
)
Downloading lh.pial...
Downloading lh.curv...
Downloading boggle.lh.annot...
Dataset downloaded successfully to images.
In [2]:
import math

import ipywidgets as widgets
from IPython.display import display

import ipyniivue

## Initialize Viewer
nv = ipyniivue.NiiVue(
    back_color=(1, 1, 1, 1),
)

nv.set_slice_type(ipyniivue.SliceType.RENDER)
nv.opts.is_colorbar = True
nv.opts.text_height = 0.03
nv.opts.tile_margin = 10

## Define Layer Indices
kCurvLayer = 0
kAtlasLayer = 1
kStatLayer = 2

## Load Mesh with Layers
mesh_layers = [
    {
        "path": DATA_FOLDER / "lh.curv",
        "colormap": "gray",
        "cal_min": 0.49,
        "cal_max": 0.51,
        "opacity": 0.5,
    },
    {
        "path": DATA_FOLDER / "boggle.lh.annot",
        "opacity": 0.01,
    },
    {
        "path": DATA_FOLDER / "boggle.lh.annot",
        "opacity": 0.5,
        "use_negative_cmap": True,
    },
]

nv.load_meshes(
    [
        {
            "path": DATA_FOLDER / "lh.pial",
            "layers": mesh_layers,
        },
    ]
)


## Configure Layers on Load

# Store initial atlas values for statistics layer
initial_atlas_values = [
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    -3,  # index 13
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    -5,  # index 21
    0,
    0,
    7,  # index 24
    0,
    0,
    4,  # index 27
    7,  # index 28
    0,
    0,
    0,
    0,
    0,
    0,
    0,
]


@nv.on_mesh_loaded
def on_mesh_loaded(mesh):
    """Handle event after mesh is loaded and ready."""
    # Configure statistics layer
    nv.meshes[0].layers[kStatLayer].cal_min = 3
    nv.meshes[0].layers[kStatLayer].cal_max = 7
    nv.meshes[0].layers[kStatLayer].colormap = "warm"
    nv.meshes[0].layers[kStatLayer].colormap_negative = "winter"
    nv.meshes[0].layers[kStatLayer].use_negative_cmap = True
    nv.meshes[0].layers[kStatLayer].atlas_values = initial_atlas_values

    # Configure layer visibility
    nv.meshes[0].layers[kCurvLayer].colorbar_visible = False
    nv.meshes[0].layers[kAtlasLayer].colorbar_visible = False
    nv.meshes[0].layers[kAtlasLayer].show_legend = False

    # Set default shader
    nv.set_mesh_shader(nv.meshes[0].id, "Rim")

    # Set default border (Opaque border = 1.0)
    nv.set_mesh_layer_property(nv.meshes[0].id, kAtlasLayer, "outline_border", 1.0)

    # Set mosaic string
    nv.opts.slice_mosaic_string = "A R 0 R -0 S R 0 R -0 C R 0 R -0"


## Create Interactive Controls

# Binary curvature checkbox
binary_curv_check = widgets.Checkbox(
    value=True, description="Binary Curvature", indent=False
)


def on_binary_curv_change(change):
    """Toggle between binary and gradient curvature display."""
    if change["new"]:
        # Binary mode
        cal_min = 0.49
        cal_max = 0.51
    else:
        # Gradient mode
        cal_min = 0.35
        cal_max = 0.65

    nv.meshes[0].layers[kCurvLayer].cal_min = cal_min
    nv.set_mesh_layer_property(nv.meshes[0].id, kCurvLayer, "cal_max", cal_max)


binary_curv_check.observe(on_binary_curv_change, names="value")

# Curvature opacity slider
curv_slider = widgets.IntSlider(
    value=50, min=0, max=100, description="Curvature", readout=False
)


def on_curv_change(change):
    """Set curve transparency."""
    if change["new"] is not None:
        nv.set_mesh_layer_property(
            mesh_id=nv.meshes[0].id,
            layer_index=kCurvLayer,
            attribute="opacity",
            value=change["new"] * 0.01,
        )


curv_slider.observe(on_curv_change, names="value")

# Border dropdown
border_options = [
    ("Dark border", -0.01),
    ("Transparent border", 0.01),
    ("No border", 0.0),
    ("Opaque border", 1.0),
]
border_dropdown = widgets.Dropdown(
    options=border_options,
    value=1.0,
    description="Border",
)


def on_border_change(change):
    """Set mesh border style."""
    nv.set_mesh_layer_property(
        nv.meshes[0].id, kAtlasLayer, "outline_border", change["new"]
    )


border_dropdown.observe(on_border_change, names="value")

# Atlas opacity slider
atlas_slider = widgets.IntSlider(
    value=1, min=0, max=100, description="Atlas", readout=False
)


def on_atlas_change(change):
    """Set atlas transparency."""
    if change["new"] is not None:
        nv.set_mesh_layer_property(
            mesh_id=nv.meshes[0].id,
            layer_index=kAtlasLayer,
            attribute="opacity",
            value=change["new"] * 0.01,
        )


atlas_slider.observe(on_atlas_change, names="value")

# Statistics opacity slider
stat_slider = widgets.IntSlider(
    value=50, min=0, max=100, description="Statistics", readout=False
)


def on_stat_change(change):
    """Set stat transparency."""
    if change["new"] is not None:
        nv.set_mesh_layer_property(
            mesh_id=nv.meshes[0].id,
            layer_index=kStatLayer,
            attribute="opacity",
            value=change["new"] * 0.01,
        )


stat_slider.observe(on_stat_change, names="value")

# Text area for custom statistics configuration
stats_text = widgets.Textarea(
    value="""cal_min = 3
cal_max = 7
colormap = 'warm'
colormap_negative = 'winter'
atlas_values = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -3, 0, 0, 0, 0, 0, 0, 0, -5, 0, 0, 7, 0, 0, 4, 7, 0, 0, 0, 0, 0, 0, 0]""",  # noqa: E501
    description="Stats Config:",
    layout=widgets.Layout(width="100%", height="150px"),
)

# Update button
update_stats_btn = widgets.Button(
    description="Update Statistics", button_style="primary"
)


def update_statistics(btn):
    """Update statistics layer properties from text input."""
    lines = stats_text.value.split("\n")
    layer = nv.meshes[0].layers[kStatLayer]

    for line in lines:
        line = line.strip()
        if not line or "=" not in line:
            continue

        parts = line.split("=")
        if len(parts) != 2:
            continue

        key = parts[0].strip()
        raw_value = parts[1].strip()

        # Skip comments
        if key.startswith("//") or key.startswith("#"):
            continue

        try:
            # Evaluate the value (handles lists, numbers, strings)
            value = eval(raw_value)

            # Set the property
            if hasattr(layer, key):
                setattr(layer, key, value)
                # Force update
                nv.set_mesh_layer_property(nv.meshes[0].id, kStatLayer, "frame_4d", 0)
        except Exception as e:
            print(f"Error setting {key}: {e}")


update_stats_btn.on_click(update_statistics)

# Mosaic checkbox
mosaic_check = widgets.Checkbox(value=True, description="Mosaic", indent=False)


def on_mosaic_change(change):
    """Toggle mosaic display."""
    if change["new"]:
        nv.opts.slice_mosaic_string = "A R 0 R -0 S R 0 R -0 C R 0 R -0"
    else:
        nv.opts.slice_mosaic_string = ""


mosaic_check.observe(on_mosaic_change, names="value")

# Save button
save_btn = widgets.Button(description="Save Bitmap", button_style="success")


def save_screenshot(btn):
    """Save current view as PNG."""
    nv.save_scene("ScreenShot.png")
    print("Saved as ScreenShot.png")


save_btn.on_click(save_screenshot)

shader_names = nv.mesh_shader_names()
shader_buttons = []


def create_shader_button(name):
    """Create a shader button."""
    btn = widgets.Button(description=name, layout=widgets.Layout(width="auto"))

    def on_click(b):
        nv.set_mesh_shader(nv.meshes[0].id, name)

    btn.on_click(on_click)
    return btn


shader_buttons = [create_shader_button(name) for name in shader_names]

# Organize shader buttons in a grid
num_cols = 10
num_rows = math.ceil(len(shader_buttons) / num_cols)
shader_grid = []

for i in range(num_rows):
    row_buttons = shader_buttons[i * num_cols : (i + 1) * num_cols]
    shader_grid.append(widgets.HBox(row_buttons))

shader_box = widgets.VBox([widgets.Label("Shaders:"), widgets.VBox(shader_grid)])

## Display Complete Interface

# Create the complete interface
interface = widgets.VBox(
    [
        # Top controls row 1
        widgets.HBox(
            [
                binary_curv_check,
                curv_slider,
                border_dropdown,
                atlas_slider,
            ]
        ),
        # Top controls row 2
        widgets.HBox(
            [
                stat_slider,
                mosaic_check,
                save_btn,
            ]
        ),
        # Main viewer
        nv,
        # Bottom controls
        stats_text,
        widgets.HBox([update_stats_btn]),
        shader_box,
    ]
)

display(interface)